"""
DoublyLinkedList is a data structure that extends the functionality of a linked list by adding a reference to the previous node in addition to the next node. This allows for efficient traversal in both directions.

Properties:
- Each node in a DoublyLinkedList contains three parts: data, a reference to the next node, and a reference to the previous node.
- The first node in the doubly linked list is called the head, and the last node is called the tail. The head's previous reference and the tail's next reference point to null, indicating the start and end of the list, respectively.
- DoublyLinkedList allows for bidirectional traversal, meaning that we can traverse both forward and backward.

Operations:

1. Initialization:
   - `DoublyLinkedList()`: Creates an empty doubly linked list.

2. Basic Operations:
   - `add_front(data)`: Adds an element to the front of the doubly linked list.
   - `add_end(data)`: Adds an element to the end of the doubly linked list.
   - `insert(data, position)`: Inserts an element at the specified position in the doubly linked list.
   - `remove(data)`: Removes the first occurrence of the specified element from the doubly linked list.
   - `remove_at(position)`: Removes the element at the specified position from the doubly linked list.
   - `search(data)`: Searches for the first occurrence of the specified element in the doubly linked list and returns its position.
   - `get(position)`: Returns the element at the specified position in the doubly linked list.
   - `size()`: Returns the number of elements in the doubly linked list.
   - `is_empty()`: Checks if the doubly linked list is empty.

Example Usage:
```
doubly_linked_list = DoublyLinkedList()

doubly_linked_list.add_front(3)
doubly_linked_list.add_end(7)
doubly_linked_list.insert(5, 1)
doubly_linked_list.remove(3)
print(doubly_linked_list.search(7))  # Output: 0
print(doubly_linked_list.get(0))  # Output: 7
print(doubly_linked_list.size())  # Output: 2
print(doubly_linked_list.is_empty())  # Output: False
```

Time and Space Complexity:
- Adding or removing elements at the front or end of a doubly linked list has a time complexity of O(1).
- Inserting or removing elements at a specific position in the doubly linked list has a time complexity of O(n), where n is the number of elements.
- Searching for an element in the doubly linked list has a time complexity of O(n), as it may require traversing the entire list.
- The space complexity of a doubly linked list is O(n), where n is the number of elements, as each element requires a node with references to the previous and next nodes.
"""

class Node:
    def __init__(self, value=None):
        """
        Initialize a node for use in a doubly linked list.

        Args:
            value: The value to be stored in the node.
            next: The reference to the next node in the list. Defaults to None.
            prev: The reference to the previous node in the list. Defaults to None.
        """
        self.value = value
        self.next = None
        self.prev = None

class DoublyLinkedList:
    def __init__(self):
        """
        Initialize an empty doubly linked list.

        Attributes:
            head (Node): The first node in the list.
            tail (Node): The last node in the list.
            length (int): The number of elements in the list.
        """
        self.head = None
        self.tail = None
        self.length = 0

    def prepend(self, value):
        """
        Insert a new element at the beginning of the list.

        Args:
            value: The value to be stored in the new node.
        """
        new_node = Node(value=value)
        if self.head is None:
            self.head = self.tail = new_node
        else:
            new_node.next = self.head
            self.head.prev = new_node
            self.head = new_node
        self.length += 1

    def append(self, value):
        """
        Insert a new element at the end of the list.

        Args:
            value: The value to be stored in the new node.
        """
        new_node = Node(value=value)
        if self.tail is None:
            self.head = self.tail = new_node
        else:
            new_node.prev = self.tail
            self.tail.next = new_node
            self.tail = new_node
        self.length += 1

    def find_by_index(self, index):
        """
        Find and return a node at a particular index.

        Args:
            index (int): The index of the node.

        Returns:
            The node at the specified index. If the index is out of range, return None.
        """
        if index < 0 or index >= self.length:
            return None
        current = self.head
        for _ in range(index):
            current = current.next
        return current

    def find_by_value(self, value):
        """
        Find and return a node with a particular value.

        Args:
            value: The value to search for.

        Returns:
            The first node that contains the value. If no such node exists, return None.
        """
        current = self.head
        while current is not None:
            if current.value == value:
                return current
            current = current.next
        return None

    def find_min_node(self):
        """
        Find and return the node with the minimum value.

        Returns:
            The node with the smallest value. If the list is empty, return None.
        """
        if self.head is None:
            return None
        min_node = self.head
        current = self.head.next
        while current is not None:
            if current.value < min_node.value:
                min_node = current
            current = current.next
        return min_node

    def __delete_node(self, node):
        """
        Delete a given node from the list.

        Args:
            node (Node): The node to be deleted.
        """
        if node.prev:
            node.prev.next = node.next
        else:
            self.head = node.next
        if node.next:
            node.next.prev = node.prev
        else:
            self.tail = node.prev
        self.length -= 1

    def __delitem__(self, index):
        """
        Delete the node at the specified index.

        Args:
            index (int): The index of the node to delete.

        Raises:
            IndexError: If the index is out of range.
        """
        if index < 0 or index >= self.length:
            raise IndexError('DoublyLinkedList index out of range')
        node = self.find_by_index(index)
        self.__delete_node(node)

    def __len__(self):
        """
        Return the number of elements in the doubly linked list.

        Returns:
            int: The number of elements in the doubly linked list.
        """
        return self.length

    def __repr__(self):
        """
        Return a machine-readable string representation of the DoublyLinkedList.

        Returns:
            str: A string representation of the DoublyLinkedList in the format '<DoublyLinkedList: head_value, tail_value, length>'.
        """
        if self.head:
            head_value = self.head.value
        else:
            head_value = None

        if self.tail:
            tail_value = self.tail.value
        else:
            tail_value = None

        return f'<DoublyLinkedList: head={head_value}, tail={tail_value}, length={self.length}>'

    def __str__(self):
        """
        Convert the doubly linked list to a string representation.

        Returns:
            str: A string representation of the doubly linked list in the format 'DoublyLinkedList: [value1 <-> value2 <-> ... <-> valueN -> None]'.
        """
        values = []
        current = self.head
        while current:
            values.append(str(current.value))
            current = current.next
        return 'DoublyLinkedList: [' + ' <-> '.join(values) + ' -> None]'


if __name__ == "__main__":
    linked_list = DoublyLinkedList()

    linked_list.append(5)
    linked_list.append(10)
    linked_list.append(15)
    linked_list.prepend(1)

    print(linked_list)  # DoublyLinkedList: [1 <-> 5 <-> 10 <-> 15 -> None]

    node = linked_list.find_by_index(2)
    if node:
        print(f"Value at index 2: {node.value}")  # Value at index 2: 10
    else:
        print("Index out of range.")

    node = linked_list.find_by_value(10)
    if node:
        print("Found node with value 10")  # Found node with value 10
    else:
        print("Value not found in linked list.")

    min_node = linked_list.find_min_node()
    if min_node:
        print(f"Node with minimum value: {min_node.value}")  # Node with minimum value: 1
    else:
        print("DoublyLinkedList is empty.")

    del linked_list[1]
    print(f"DoublyLinkedList after deleting value at index 1: {linked_list}")  # DoublyLinkedList after deleting value at index 1: [1 <-> 10 <-> 15 -> None]

    print(repr(linked_list))  # <DoublyLinkedList: head=1, tail=15, length=3>
